Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

134 linesCodeBlameRaw
1import { Stack, router, useLocalSearchParams } from "expo-router";
2import * as WebBrowser from "expo-web-browser";
3import { Check, ExternalLink, MessageSquareWarning } from "lucide-react-native";
4import { useState } from "react";
5import { View } from "react-native";
6
7import { Body, Checks, Diff, StateBadge, Timeline } from "@/components/code";
8import { Segments } from "@/components/segments";
9import { Button, ErrorView, Field, Loading, Row, Screen, Text } from "@/components/ui";
10import { ItemIcon, stateLabel } from "@/components/work";
11import { usePull, usePullChanges, useReview } from "@/lib/code";
12import { siteUrl } from "@/lib/server";
13import { useSignedIn } from "@/lib/session";
14import { ago } from "@/lib/time";
15import { space } from "@/theme";
16
17/**
18 * A pull request: what it is for, its checks, the conversation, and what
19 * it changes; approve it or ask for changes from here. Merging stays on
20 * the site for now, where its rules and the merge queue are shown.
21 */
22export default function PullRequest() {
23 const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>();
24 const number = Number(params.number);
25 const { server } = useSignedIn();
26 const [tab, setTab] = useState<"conversation" | "files">("conversation");
27 const pull = usePull(params.owner, params.repo, number);
28 const changes = usePullChanges(params.owner, params.repo, number, tab === "files");
29 const review = useReview(params.owner, params.repo, number);
30 const [note, setNote] = useState("");
31
32 const open = () => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/pull/${number}`));
33
34 if (pull.isPending) {
35 return (
36 <Screen>
37 <Stack.Screen options={{ title: `#${number}` }} />
38 <Loading />
39 </Screen>
40 );
41 }
42 if (pull.error) {
43 return (
44 <Screen>
45 <Stack.Screen options={{ title: `#${number}` }} />
46 <ErrorView error={pull.error} retry={() => pull.refetch()} />
47 </Screen>
48 );
49 }
50
51 const { pull: pr, item, comments } = pull.data;
52 const by = pr.author.display_username ?? pr.author.username;
53 const reviewable = pr.status === "open";
54 const refs = { repo: `${params.owner}/${params.repo}` };
55
56 return (
57 <Screen scroll>
58 <Stack.Screen options={{ title: `#${number}` }} />
59 <View style={{ gap: space.sm }}>
60 <Text size="heading">{pr.title}</Text>
61 <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center", flexWrap: "wrap" }}>
62 <StateBadge pull={pr} />
63 <Checks status={pr.check_status} />
64 <Text size="small" tone="muted">
65 {by}
66 {pr.agent ? ` with ${pr.agent}` : ""} · {pr.branch ?? "a fork"} → {pr.base} · {ago(pr.updated_at)}
67 </Text>
68 </View>
69 </View>
70
71 {item ? (
72 <View style={{ marginHorizontal: -space.lg }}>
73 <Row
74 leading={<ItemIcon item={item} />}
75 title={item.title}
76 subtitle={`For work item #${item.number} · ${stateLabel(item)}`}
77 onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })}
78 />
79 </View>
80 ) : null}
81
82 <Segments
83 value={tab}
84 onChange={setTab}
85 options={[
86 { value: "conversation", label: "Conversation" },
87 { value: "files", label: `Files · ${pr.files.length}` },
88 ]}
89 />
90
91 {tab === "conversation" ? (
92 <>
93 <Body text={pr.body} refs={refs} />
94 <Timeline comments={comments} refs={refs} />
95 {reviewable ? (
96 <View style={{ gap: space.md }}>
97 <Field label="Review" placeholder="Say what you think (optional to approve)" value={note} onChangeText={setNote} multiline />
98 <View style={{ flexDirection: "row", gap: space.sm }}>
99 <View style={{ flex: 1 }}>
100 <Button
101 title="Approve"
102 icon={Check}
103 busy={review.isPending && review.variables?.verdict === "approve"}
104 disabled={review.isPending}
105 onPress={() => review.mutate({ verdict: "approve", body: note.trim() }, { onSuccess: () => setNote("") })}
106 />
107 </View>
108 <View style={{ flex: 1 }}>
109 <Button
110 title="Request changes"
111 variant="secondary"
112 icon={MessageSquareWarning}
113 busy={review.isPending && review.variables?.verdict === "request_changes"}
114 disabled={review.isPending || !note.trim()}
115 onPress={() => review.mutate({ verdict: "request_changes", body: note.trim() }, { onSuccess: () => setNote("") })}
116 />
117 </View>
118 </View>
119 {review.error ? <Text tone="danger">{review.error.message}</Text> : null}
120 </View>
121 ) : null}
122 </>
123 ) : changes.isPending ? (
124 <Loading />
125 ) : changes.error ? (
126 <ErrorView error={changes.error} retry={() => changes.refetch()} />
127 ) : (
128 <Diff changes={changes.data} />
129 )}
130
131 <Button title="Open on g1t" variant="secondary" icon={ExternalLink} onPress={open} />
132 </Screen>
133 );
134}